iT邦幫忙

2025 iThome 鐵人賽

DAY 6
1

昨天我們讓 /search API 成形,雖然只是回假資料,但已經建立了 API 雛型。

今天要讓服務更可靠:加入 context/timeout

這一步很重要,因為:

  • 真實情境下,/search 會呼叫下游(Elasticsearch、DB、甚至外部 API)。
  • 如果下游卡住,沒有 timeout,整個請求就會卡死。
  • context 能幫我們在時間到時,自動取消請求,釋放資源。
    • 資源指的是一切和這個請求綁定的東西,可能是 goroutine, 網路連線, DB 連線, 記憶體(channel / buffer / object), 檔案讀寫, I/O 操作

Step 1:為什麼要 timeout?

舉例:

  • 沒有 timeout → 如果下游 ES 卡了 30 秒,整個 Go handler 就被佔住 30 秒,併發效能瞬間崩壞。
  • 有了 timeout → 例如 2 秒,超時就自動返回 504 Gateway Timeout(或自定義錯誤),請求不會無限佔住資源。

Step 2:改寫 /search Handler

修改 main.go 裡的 searchHandler,加入 context with timeout。

我們模擬「下游呼叫」用 time.Sleep,但會被 context 控制。

2-1: 加入 context / timeout

// 1) 從當前請求的 context 衍生一個有時限的 ctx
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

// 3) 監聽 ctx,時間到(或客戶端中斷)就走這裡
select {
case <-ctx.Done():
    http.Error(w, "search timeout", http.StatusGatewayTimeout)
    return
// ...
}

2-2: 模擬下游呼叫(假裝去查 ES/DB)

// 2) 開 goroutine 模擬「呼叫下游」需要時間
resultCh := make(chan SearchResponse, 1)
go func() {
    // 假裝下游很慢:睡 3 秒(故意比 2 秒 timeout 還長)
    time.Sleep(3 * time.Second)
    resultCh <- SearchResponse{
        Query: query,
        Hits: []SearchResult{
            {ID: 1, Title: "Learning Go"},
            {ID: 2, Title: "Go Concurrency Patterns"},
        },
    }
}()

2-3: 主流程如何銜接兩者

select {
case <-ctx.Done():           // ←(A)context 超時/取消
    http.Error(w, "search timeout", http.StatusGatewayTimeout)
    return
case resp := <-resultCh:     // ←(B)下游結果回來
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(resp)
}

完整的 main.go

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

// 假資料
type SearchResult struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

type SearchResponse struct {
	Query string         `json:"query"`
	Hits  []SearchResult `json:"hits"`
}

func searchHandler(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query().Get("q")
	if query == "" {
		http.Error(w, "missing query parameter: q", http.StatusBadRequest)
		return
	}

	// 建立帶有 timeout 的 context
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	// 模擬下游呼叫(例如 Elasticsearch)
	resultCh := make(chan SearchResponse, 1)
	go func() {
		// 假裝需要 3 秒(比 timeout 長)
		time.Sleep(3 * time.Second)
		resultCh <- SearchResponse{
			Query: query,
			Hits: []SearchResult{
				{ID: 1, Title: "Learning Go"},
				{ID: 2, Title: "Go Concurrency Patterns"},
			},
		}
	}()

	select {
	case <-ctx.Done():
		// timeout 或被取消
		http.Error(w, "search timeout", http.StatusGatewayTimeout)
		return
	case resp := <-resultCh:
		// 正常拿到結果
		w.Header().Set("Content-Type", "application/json")
		if err := json.NewEncoder(w).Encode(resp); err != nil {
			http.Error(w, fmt.Sprintf("encode error: %v", err), http.StatusInternalServerError)
		}
	}
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok")
}

func main() {
	cfg := LoadConfig()

	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthHandler)
	mux.HandleFunc("/search", searchHandler)

	handler := LoggingMiddleware(RecoveryMiddleware(mux))

	log.Printf("Server listening on %s", cfg.Port)
	if err := http.ListenAndServe(cfg.Port, handler); err != nil {
		log.Fatal(err)
	}
}


Step 3:測試 Timeout 行為

新增 search_timeout_test.go

package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"
)

// 目前的實作:handler 會在 2s 超時、下游模擬 3s -> 必定 504
func TestSearchHandler_ServerTimeout(t *testing.T) {
	t.Parallel()

	req := httptest.NewRequest(http.MethodGet, "/search?q=golang", nil)
	rr := httptest.NewRecorder()

	// 直接呼叫目前的 handler
	searchHandler(rr, req)

	if rr.Code != http.StatusGatewayTimeout {
		t.Fatalf("status got %d, want %d", rr.Code, http.StatusGatewayTimeout)
	}
	want := "search timeout\n"
	if rr.Body.String() != want {
		t.Fatalf("body got %q, want %q", rr.Body.String(), want)
	}
}

// 客戶端主動取消(早於 2s timeout),也應拿到 504
func TestSearchHandler_ClientCancel(t *testing.T) {
	t.Parallel()

	// 建立一個會在 200ms 取消的 context,包在 request 裡
	parent := context.Background()
	ctx, cancel := context.WithTimeout(parent, 200*time.Millisecond)
	defer cancel()

	req := httptest.NewRequest(http.MethodGet, "/search?q=golang", nil).WithContext(ctx)
	rr := httptest.NewRecorder()

	searchHandler(rr, req)

	if rr.Code != http.StatusGatewayTimeout {
		t.Fatalf("status got %d, want %d", rr.Code, http.StatusGatewayTimeout)
	}
}

// 缺少 q 參數 -> 400
func TestSearchHandler_BadRequest(t *testing.T) {
	t.Parallel()

	req := httptest.NewRequest(http.MethodGet, "/search", nil)
	rr := httptest.NewRecorder()

	searchHandler(rr, req)

	if rr.Code != http.StatusBadRequest {
		t.Fatalf("status got %d, want %d", rr.Code, http.StatusBadRequest)
	}
}


Step 4:執行測試

  • main.go 模擬 timeout 的程式碼會導致其他測試失敗,所以只執行測試 search_timeout_test.go 避免雜訊
  • search_timeout_test.go 測試 main.go 的 searchHandler。由於 Go 編譯器需要編譯整個 main.go 文件(包含 main()
    ),而 main() 引用了其他檔案的 function,所以執行測試時需要包含所有相關的 .go 檔案才能通過編譯。
go test -v main.go middleware.go config.go search_timeout_test.go

預期結果:

https://ithelp.ithome.com.tw/upload/images/20250920/201383314ZU2YgSj9S.png


小結

今天我們完成了:

  • 使用 context.WithTimeout/search 加上 timeout
  • 模擬下游延遲 → 能正確回 504 Gateway Timeout
  • 撰寫測試,驗證 timeout 與快速回應的兩種情境

這讓服務更健壯,未來即使 Elasticsearch 出現延遲或故障,也不會拖垮整個 Go 服務。


👉 明天(Day 7),我們要進一步優化 錯誤策略:用 %w 包裝錯誤,並根據錯誤類型做分類重試(退避 + 抖動)。



上一篇
Day 5 - API 成形:先回假資料的 /search
系列文
用 Golang + Elasticsearch + Kubernetes 打造雲原生搜尋服務6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言